Python 51.6%
TypeScript 46.7%
CSS 1.7%
1import type { Metadata } from "next";2import Link from "next/link";3import { notFound } from "next/navigation";4import { apiSafe } from "@/lib/api";5import { PartyBadge, PartyDot, PartyLogo } from "@/components/party";6import { Badge, Card, KV, LiveBadge, ProbabilityBar, ProjectionBadge, SectionHeader, SourceBadge } from "@/components/ui";7import { RidingMap } from "@/components/riding-map";8import { RidingLive } from "@/components/riding-live";9import { dateFr, dateTimeFr, delta, int, pct, prob, ratingLabel } from "@/lib/format";10import { partyVar, partyLabel, partyName } from "@/lib/parties";1112export const revalidate = 60;1314interface Detail {15 code: number; name: string; slug: string; region: string | null; regionName: string | null; electors: number | null; areaKm2: number | null; isNew: boolean; changed: boolean; centroid: [number, number];16 incumbent: { party: string | null; name: string | null; running: boolean | null };17 baseline2022: { party: string; share: number; votes: number; method: string; coverage: number | null; source: string }[];18 candidates: { id: number; name: string; party: string | null; partyLabel: string | null; independent: boolean; incumbent: boolean; updatedAt: string | null }[];19 candidatesSource: { name: string; url: string };20 history: Record<string, { party: string | null; label: string; candidate: string; votes: number; share: number; elected: boolean; turnout: number | null }[]>;21 historyNote: string;22 forecast?: { runId: number; completedAt: string | null; modelVersion: string; favorite: string; p: number; runnerUp: string | null; margin: number; rating: string; volatility: number; swing: number; factors: { factor: string; label: string; detail: string; direction: string }[]; parties: { party: string; voteMean: number; vote80: [number, number]; pWin: number }[] };23 live: { status: string; updatedAt: string | null; bureaux: [number, number]; votesValid: number; turnout: number | null; leader: string | null; marginVotes: number | null; marginPct: number | null; candidates: { name: string; party_id: string; votes: number; share: number }[] | null; final: boolean } | null;24 nearby?: { code: number; name: string; slug: string }[];25 demographics?: { population2021: number; density: number | null; medianAge: number | null; pct65Plus: number | null; pctFrenchMt: number | null; pctEnglishMt: number | null; pctOtherMt: number | null; pctPlopFrench: number | null; pctImmigrants: number | null; pctVisibleMinority: number | null; medianHouseholdIncome: number | null; pctOwner: number | null; pctBachelorPlus: number | null; unemploymentRate: number | null; adaCount: number; source: string } | null;26 pollRegion?: string;27}2829export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {30 const { slug } = await params;31 const d = await apiSafe<Detail>(`/api/ridings/${slug}`);32 if (!d) return { title: "Circonscription" };33 const fav = d.forecast ? `${partyLabel(d.forecast.favorite)} favori (${prob(d.forecast.p)})` : "";34 return { title: `${d.name} — Prévision élection Québec 2026`, description: `${d.name} (${d.regionName}) : ${fav}. Candidat·es, résultat 2022 transposé, probabilité de victoire par parti et facteurs du modèle QC26.`, alternates: { canonical: `/circonscription/${d.slug}` } };35}3637export default async function Page({ params }: { params: Promise<{ slug: string }> }) {38 const { slug } = await params;39 const d = await apiSafe<Detail>(`/api/ridings/${slug}`);40 if (!d) notFound();41 const f = d.forecast;42 const years = Object.keys(d.history).sort().reverse();43 return (44 <div className="space-y-10">45 <div className="grid lg:grid-cols-[1fr_360px] gap-6 items-start">46 <div>47 <div className="flex items-center gap-2 flex-wrap"><Link href="/circonscriptions" className="text-[12.5px] text-ink-3 hover:text-ink">← Circonscriptions</Link><Badge>{d.regionName}</Badge>{d.isNew && <Badge tone="accent">Nouvelle ou renommée en 2026</Badge>}{!d.isNew && d.changed && <Badge>Limites modifiées</Badge>}{d.live && d.live.status !== "NOT_STARTED" && <LiveBadge label={d.live.final ? "Final" : "Direct"} />}</div>48 <h1 className="display text-[38px] md:text-[56px] mt-3">{d.name}</h1>49 <div className="mt-2 text-[14px] text-ink-2 num">{int(d.electors)} électeurs inscrits{d.areaKm2 ? ` · ${int(d.areaKm2)} km²` : ""} · sortant·e : {d.incumbent.name ? `${d.incumbent.name} (${partyLabel(d.incumbent.party)})${d.incumbent.running === false ? ", ne se représente pas" : d.incumbent.running ? ", candidat·e" : ""}` : "—"}</div>50 {f && (51 <div className="mt-6 card card-lg p-5 md:p-6">52 <div className="flex items-center justify-between flex-wrap gap-2"><div className="eyebrow">Projection QC26 · run #{f.runId} · {dateTimeFr(f.completedAt)}</div><ProjectionBadge /></div>53 <div className="mt-3 flex items-end gap-6 flex-wrap">54 <div><div className="flex items-center gap-2"><PartyLogo id={f.favorite} height={22} /><span className="font-semibold">{partyName(f.favorite)} favori</span></div><div className="num display text-[64px]" style={{ color: partyVar(f.favorite) }}>{prob(f.p)}</div><div className="text-[13px] text-ink-2 -mt-1">probabilité de victoire · <b>{ratingLabel(f.rating)}</b></div></div>55 <div className="ml-auto grid grid-cols-2 gap-x-6 gap-y-3 text-[13px]"><div><div className="eyebrow">Marge projetée</div><div className="num text-[22px] font-bold">{f.margin.toFixed(1).replace(".", ",")} pt</div><div className="text-ink-3">vs {partyLabel(f.runnerUp)}</div></div><div><div className="eyebrow">Swing vs 2022</div><div className="num text-[22px] font-bold">{delta(f.swing, 1, " pt")}</div><div className="text-ink-3">{partyLabel(f.favorite)}</div></div></div>56 </div>57 <div className="mt-5 space-y-2">58 {f.parties.filter((p) => p.party !== "aut" || p.voteMean > 2).map((p) => (59 <div key={p.party} className="grid grid-cols-[46px_1fr_92px_60px] items-center gap-3 text-[13px]">60 <span className="font-semibold num" style={{ color: partyVar(p.party) }}>{partyLabel(p.party)}</span>61 <div className="relative h-[14px] bg-surface-3 rounded-[4px] overflow-hidden"><div className="absolute inset-y-0 rounded-[4px]" style={{ left: `${(p.vote80[0] / 70) * 100}%`, width: `${((p.vote80[1] - p.vote80[0]) / 70) * 100}%`, background: partyVar(p.party), opacity: 0.3 }} /><div className="absolute inset-y-0 w-[2px] bg-ink" style={{ left: `${(p.voteMean / 70) * 100}%` }} /></div>62 <span className="num text-ink-2">{p.voteMean.toFixed(1).replace(".", ",")} % <span className="text-ink-3 text-[11px]">({p.vote80[0].toFixed(0)}–{p.vote80[1].toFixed(0)})</span></span>63 <span className="num font-semibold text-right">{prob(p.pWin)}</span>64 </div>65 ))}66 <div className="text-[11.5px] text-ink-3">Vote estimé (moyenne, intervalle 80 %) et probabilité de victoire.</div>67 </div>68 </div>69 )}70 </div>71 <div className="space-y-4">72 <Card pad={false} className="overflow-hidden"><RidingMap code={d.code} /></Card>73 {d.nearby && <Card><div className="eyebrow mb-2">Circonscriptions voisines</div><div className="flex flex-wrap gap-1.5">{d.nearby.map((n) => <Link key={n.code} href={`/circonscription/${n.slug}`} className="text-[12.5px] rounded-full border border-border px-2.5 py-1 hover:bg-surface-2">{n.name}</Link>)}</div></Card>}74 </div>75 </div>7677 {d.live && d.live.status !== "NOT_STARTED" && <RidingLive code={d.code} initial={d.live} forecast={f ? { favorite: f.favorite, p: f.p } : null} />}7879 {f && (80 <section>81 <SectionHeader eyebrow="Pourquoi ?" title={`Pourquoi QC26 donne ${partyLabel(f.favorite)} favori ?`} description="Seuls les facteurs réellement utilisés par le modèle sont listés — aucune explication éditoriale." />82 <div className="grid md:grid-cols-2 gap-3">83 {f.factors.map((x, i) => (84 <div key={i} className="card p-4 flex gap-3"><span className={"num font-bold text-[18px] w-6 shrink-0 " + (x.direction === "+" ? "text-ok" : x.direction === "-" ? "text-live" : "text-ink-3")}>{x.direction}</span><div><div className="font-semibold text-[14px]">{x.label}</div><div className="text-[13px] text-ink-2 mt-0.5">{x.detail}</div></div></div>85 ))}86 </div>87 </section>88 )}8990 <section className="grid lg:grid-cols-2 gap-6">91 <div>92 <SectionHeader eyebrow="Candidatures" title="Candidat·es" description="Liste officielle d'Élections Québec, mise à jour toutes les 4 heures." action={<SourceBadge source={{ name: d.candidatesSource.name, url: d.candidatesSource.url, tier: "Officiel (DGEQ)" }} />} />93 <Card pad={false}>94 {d.candidates.length === 0 ? <div className="p-6 text-center text-ink-3 text-[13.5px]">Aucune candidature enregistrée pour l'instant.</div> : (95 <table className="data-table"><thead><tr><th>Candidat·e</th><th>Parti</th><th></th></tr></thead><tbody>{d.candidates.sort((a, b) => (f?.parties.find((p) => p.party === b.party)?.pWin ?? 0) - (f?.parties.find((p) => p.party === a.party)?.pWin ?? 0)).map((c) => (<tr key={c.id}><td className="font-medium">{c.name}</td><td>{c.party ? <span className="inline-flex items-center gap-1.5"><PartyDot id={c.party} />{partyLabel(c.party)}</span> : <span className="text-ink-2">{c.independent ? "Indépendant·e" : c.partyLabel}</span>}</td><td className="r text-[11.5px] text-ink-3">{c.incumbent ? "Sortant·e" : ""}</td></tr>))}</tbody></table>96 )}97 </Card>98 </div>99 <div>100 <SectionHeader eyebrow="2022 sur la carte 2026" title="Résultat précédent transposé" description="Résultats officiels 2022 par section de vote, réaffectés aux limites 2026 (calcul QC26)." action={<SourceBadge source={{ name: "Élections Québec — résultats 2022 par section de vote + carte 2026", url: "https://www.dgeq.org/donnees.html", detail: d.baseline2022[0]?.source, tier: "Officiel (DGEQ) · transposition QC26" }} />} />101 <Card>102 <div className="space-y-2">{d.baseline2022.filter((b) => b.party !== "aut" || b.share > 1).map((b) => (<div key={b.party} className="grid grid-cols-[46px_1fr_64px] items-center gap-3 text-[13px]"><span className="font-semibold num" style={{ color: partyVar(b.party) }}>{partyLabel(b.party)}</span><div className="h-[12px] bg-surface-3 rounded-[4px] overflow-hidden"><div className="h-full" style={{ width: `${(b.share / 70) * 100}%`, background: partyVar(b.party) }} /></div><span className="num text-right">{pct(b.share)}</span></div>))}</div>103 <div className="text-[12px] text-ink-3 mt-3">{d.baseline2022[0]?.coverage !== null && d.baseline2022[0]?.coverage !== undefined ? `${pct(d.baseline2022[0].coverage * 100, 0)} des votes proviennent de la circonscription 2022 principale.` : ""} Hypothèse : les votes par anticipation se répartissent comme ceux du jour du vote.</div>104 </Card>105 </div>106 </section>107108 {d.demographics && (109 <section>110 <SectionHeader eyebrow="Démographie" title="Recensement 2021" description="Statistique Canada, agrégé aux limites 2026 par intersection des aires de diffusion agrégées. La part francophone alimente le swing par segment du modèle." action={<SourceBadge source={{ name: "Statistique Canada — Recensement 2021 (profil ADA + limites)", url: "https://www12.statcan.gc.ca/census-recensement/2021/dp-pd/prof/details/download-telecharger.cfm?Lang=F", detail: `${d.demographics.adaCount} aires de diffusion agrégées intersectées`, tier: "Officiel (StatCan) · agrégation QC26" }} />} />111 <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3">112 {([113 ["Population 2021", int(d.demographics.population2021), ""],114 ["Langue maternelle française", d.demographics.pctFrenchMt, " %"],115 ["Langue maternelle anglaise", d.demographics.pctEnglishMt, " %"],116 ["Autres langues maternelles", d.demographics.pctOtherMt, " %"],117 ["Immigrant·es", d.demographics.pctImmigrants, " %"],118 ["Minorités visibles", d.demographics.pctVisibleMinority, " %"],119 ["65 ans et plus", d.demographics.pct65Plus, " %"],120 ["Âge médian", d.demographics.medianAge, " ans"],121 ["Revenu médian des ménages", d.demographics.medianHouseholdIncome ? int(d.demographics.medianHouseholdIncome) : null, " $"],122 ["Propriétaires", d.demographics.pctOwner, " %"],123 ["Baccalauréat ou plus (25-64)", d.demographics.pctBachelorPlus, " %"],124 ["Taux de chômage", d.demographics.unemploymentRate, " %"],125 ["Densité", d.demographics.density ? int(d.demographics.density) : null, " hab./km²"],126 ["Regroupement sondeurs", d.pollRegion === "montreal_rmr" ? "Grand Montréal" : d.pollRegion === "quebec_rmr" ? "Région de Québec" : "Reste du Québec", ""],127 ] as [string, string | number | null, string][]).map(([label, value, suffix]) => (128 <div key={label} className="card p-3"><div className="text-[11px] text-ink-3 leading-tight">{label}</div><div className="num font-bold text-[18px] mt-1">{value === null || value === undefined ? "—" : typeof value === "number" ? value.toLocaleString("fr-CA", { maximumFractionDigits: 1 }) : value}<span className="text-[12px] font-medium text-ink-2">{value === null ? "" : suffix}</span></div></div>129 ))}130 </div>131 </section>132 )}133134 {years.length > 0 && (135 <section>136 <SectionHeader eyebrow="Historique" title="Résultats officiels" description={d.historyNote} />137 <div className="grid md:grid-cols-2 gap-4">138 {years.map((y) => (139 <Card key={y} pad={false}>140 <div className="px-4 py-2.5 border-b border-border flex items-center justify-between"><b>{dateFr(y)}</b><span className="text-[12px] text-ink-3 num">participation {pct(d.history[y][0]?.turnout)}</span></div>141 <table className="data-table"><tbody>{d.history[y].slice(0, 6).map((h, i) => (<tr key={i}><td className="w-6">{h.elected && <span title="Élu·e" className="text-ok">●</span>}</td><td>{h.candidate}</td><td><span className="inline-flex items-center gap-1.5"><PartyDot id={h.party} />{h.party ? partyLabel(h.party) : h.label}</span></td><td className="r num">{int(h.votes)}</td><td className="r num font-semibold">{pct(h.share)}</td></tr>))}</tbody></table>142 </Card>143 ))}144 </div>145 </section>146 )}147148 <Card>149 <div className="eyebrow mb-2">Fiche</div>150 <KV items={[["Code DGEQ", String(d.code)], ["Région (classification QC26)", d.regionName ?? "—"], ["Électeurs inscrits (décret 2026)", int(d.electors)], ["Superficie approx.", d.areaKm2 ? `${int(d.areaKm2)} km²` : "—"], ["Sortant·e", d.incumbent.name ? `${d.incumbent.name} · ${partyName(d.incumbent.party)}` : "—"], ["Volatilité (1 − p favori)", f ? f.volatility.toFixed(2) : "—"], ["Dernière mise à jour", f ? dateTimeFr(f.completedAt) : "—"]]} />151 <div className="mt-3 flex gap-2 flex-wrap"><PartyBadge id={f?.favorite} /><ProbabilityBar p={f?.p ?? 0} color={partyVar(f?.favorite)} className="flex-1 min-w-[200px]" /></div>152 </Card>153 </div>154 );155}156